Profile picture

[AWS] AWS Lambda로 주기적으로 S3에만 저장된 파일 제거하기

Amaranth2023년 12월 10일

들어가며


Kerdy Image 처리 작업 성능 개선기이미지 업로드 실패에 대한 처리문제를 해결하기 위해 Lambda 함수를 도입하는 과정을 정리하였다. 나와 비슷한 목적으로 AWS Lambda 함수를 사용하려는 분들께 도움이 되길 바란다.

과정


1. AWS Lambda 생성

AWS Lambda 콘솔에서 람다 함수를 생성해보자. Lambda>함수로 이동한 뒤 함수 생성 버튼을 클릭한다.

함수 이름과 런타임(코드 작성 언어)를 기입하고, 함수 생성 버튼을 클릭해 람다 함수를 생성해준다.

2. S3, RDS 접근 권한 설정 - IAM Role, VPC

AWS Lambda 함수에서 S3에 접근할 수 있도록, S3 접근 권한을 가진 역할을 생성하여 Lambda 함수에 부여해주어야 한다. IAM 콘솔로 이동하고, IAM>역할로 접근해 '역할 생성'을 클릭한다. Lambda에 부여할 역할이므로, 사용 사례를 Lambda로 선택해준다. 다음으로, 역할에 권한을 줄 차례이다. S3에 접근하기 위해 AmazonS3FullAccess 정책을 찾아 체크해준다. S3에 대한 모든 접근 권한을 허용한다는 의미이다. 그리고 RDS에 접근하기 위해 AWSLambdaVPCAccessExecutionRole 정책을 찾아 체크한다. 동일한 VPC 상에 있는 서비스에 대한 접근 권한을 허용한다는 의미이다.

이렇게 역할을 생성해주면, ARN이라는 걸 확인할 수 있는데 이건 나중에 RDS와 연동할 때 사용될 정보이니 기억해두도록 하자.

이제 다시 Lambda 콘솔로 돌아가 1단계에서 만들었던 함수 페이지에 접속한다. 구성 > 권한에서 실행 역할을 확인할 수 있는데, 여기서 편집을 클릭해 앞서 만들었던 역할을 설정해준다.

이렇게 해서 내가 만든 람다 함수가 S3에 접근할 수 있게 되었다.

하지만 아직은 커디의 보안 정책 상 RDS에 접근할 수 없다. RDS 접근 권한을 얻기 위해서는 몇 가지 작업을 더 해야 한다. RDS는 동일한 VPC 상에 존재하는 인스턴스로부터의 접근을 허용하고 있으므로, Lambda 함수를 RDS와 같은 VPC 상에 두어야 한다.

먼저, RDS 콘솔로 들어가 RDS의 VPC, 서브넷, VPC 보안 그룹 정보를 확인한다.

다시 Lambda 콘솔로 돌아가서, 구성 > VPC로 들어가 편집을 클릭한다. 그럼 VPC, 서브넷, 보안그룹을 기입하는 란이 나오는데, 모두 RDS의 VPC 정보와 동일하게 설정한다.

그런데 여기서 주의할 점이 있다.

S3는 VPC 외부에 있기 때문에, Lambda 함수에 VPC를 할당해주면 S3에 접근할 수가 없게 된다. 때문에 VPC에서 S3에 접근할 수 있도록 VPC 엔드포인트를 추가해주어야 한다.

VPC 엔드포인트 중에서도 Gateway Endpoint를 추가하기로 했다.(이 부분에 대해서는 나중에 좀 더 알아봐야겠다.)

VPC 콘솔로 들어가 VPC>Endpoint에 접속하고, 엔드포인트 생성을 클릭한다.

엔드포인트 이름을 짓고

엔드포인트를 연결할 서비스(s3)를 Gateway 유형으로 선택하고

엔드포인트를 만들 VPC와 라우팅 테이블을 선택한다. 이렇게 해서 엔드포인트를 생성한다.

이제 Lambda 함수가 S3, RDS에 접근하기 위한 인프라 차원에서의 준비는 끝났다.

3. Java 템플릿 코드 준비

이제 Lamda 함수에 올릴 코드를 작성해주어야 한다. 먼저, 템플릿으로 blank-java 코드를 다운 받는다.

blank-java 코드를 가져와서 build.gradle 파일을 확인해보면, 다음과 같이 Lambda에서 Java를 실행하기 위해 필요한 의존성이 추가되어 있는 것을 확인할 수 있다.

이제 build.gradle 파일에 java 코드를 zip 파일로 빌드하기 위한 buildZip task를 작성해주고, 실행시켜보자.

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from configurations.runtimeClasspath
    }
}

그럼 이렇게 zip 파일이 나온다. 이 파일을 준비해두자.

4. 배포 패키지 업로드 및 핸들러 메서드 정의

다시 Lambda 콘솔로 돌아가 1단계에서 만든 람다 함수 페이지로 이동한다. 코드 소스에서 3번 단계에서 만든 zip 파일을 업로드한다.

그 다음, 런타임 설정에서 편집에 들어가 핸들러를 지정해준다.

blank-java 소스코드의 초기 핸들러는 다음과 같이 작성되어 있다.

// Handler value: example.Handler  
public class Handler implements RequestHandler<Map<String,String>, String> {  
  
    private static final LambdaClient lambdaClient = LambdaClient.builder().build();  
  
    @Override  
    public String handleRequest(Map<String,String> event, Context context) {  
  
        LambdaLogger logger = context.getLogger();  
        logger.log("Handler invoked");  
  
        GetAccountSettingsResponse response = null;  
        try {  
            response = lambdaClient.getAccountSettings();  
        } catch(LambdaException e) {  
            logger.log(e.getMessage());  
        }  
        return response != null ? "Total code size for your account is " + response.accountLimit().totalCodeSize() + " bytes" : "Error";  
    }  
}

example 패키지에 위치한 Handler 클래스의 handleRequest 메서드를 핸들러로 등록한다는 의미로, 핸들러 란에 exaple.Handler::handleRequest를 기입해주었다.

5. 테스트 이벤트 생성 및 테스트 실행

Lambda 함수 페이지의 테스트 섹션으로 이동하면 다음과 같이 테스트 이벤트를 생성하는 폼이 뜬다.

여기서 테스트를 클릭하면 람다 함수에 등록된 핸들러 함수가 실행된다. 다시 핸들러 코드를 가져와보면, LambdaClient의 계정 설정을 조회하는 로직이 포함되어 있음을 확인할 수 있다.

// Handler value: example.Handler  
public class Handler implements RequestHandler<Map<String,String>, String> {  
  
    private static final LambdaClient lambdaClient = LambdaClient.builder().build();  
  
    @Override  
    public String handleRequest(Map<String,String> event, Context context) {  
  
        LambdaLogger logger = context.getLogger();  
        logger.log("Handler invoked");  
  
        GetAccountSettingsResponse response = null;  
        try {  
            response = lambdaClient.getAccountSettings();  
        } catch(LambdaException e) {  
            logger.log(e.getMessage());  
        }  
        return response != null ? "Total code size for your account is " + response.accountLimit().totalCodeSize() + " bytes" : "Error";  
    }  
}

지금으로서는 Lambda 함수가 클라이언트의 계정 정보에 접근할 권한이 없기 때문에, 테스트를 실행 시키면 에러 로그가 출력된다. 지금 요구사항에서 필요한 코드는 아니니, 일단은 무시하도록 하자.

6. 본격적인 코드 작성

이제 본격적으로 S3의 고아 이미지를 제거해주는 코드를 작성해보자. 우리가 작성해야 할 로직은 다음과 같다.

  1. S3의 이미지 (이름)목록을 불러온다.
  2. RDS image 테이블의 (name)목록을 불러온다.
  3. 두 목록을 서로 비교하여 S3에만 존재하는 이미지들만 필터링한다.
  4. 필터링된 이미지들을 S3에서 제거한다.

차례대로 살펴보자.

  1. S3의 이미지 목록 불러오기

    먼저 Lambda 함수 코드에서 S3에 접근할 수 있도록 의존성을 추가한다.

    // AWS S3  
    implementation platform('com.amazonaws:aws-java-sdk-bom:1.11.1000')  
    implementation 'com.amazonaws:aws-java-sdk-s3:1.12.445'

    그리고 다음과 같이 코드를 작성해서, S3에 존재하는 이미지 목록을 불러와보자.

    // Handler value: example.Handler  
    public class Handler implements RequestHandler<Map<String,String>, String> {  
      
        private static final String S3_BUCKET_NAME = "kerdy-dev";  
        private static final String S3_PATH = "dev/";   
        private AmazonS3 s3Client;  
        @Override  
        public String handleRequest(Map<String,String> event, Context context) { 
            LambdaLogger logger = context.getLogger();  
            try {  
    	        s3Client = AmazonS3ClientBuilder.standard()  
    	                .withRegion(Regions.AP_NORTHEAST_2)  
    	                .build();  
    	        List<String> s3ImageKeys = listObjectsInS3();  
            
                logger.log("S3에 저장된 이미지 개수:" + s3ImageNames.size() + "개\n");  
                for (int i = 0; i < 10; i++) {  
                    logger.log(s3ImageNames.get(i));  
                }   
                return "Success";  
            }catch(Exception e){  
                logger.log(e.getMessage());
                return "Error"  
            }  
        }  
        private List<String> listObjectsInS3() {  
            return s3Client.listObjects(S3_BUCKET_NAME, S3_PATH)  
                    .getObjectSummaries()  
                    .stream()  
                    .map(summary->summary.getKey().substring(S3_PATH.length()))  
                    .collect(Collectors.toList());  
        }  
      
    }

    테스트를 돌리면 S3에 있는 이미지 이름이 성공적으로 불러와지는 것을 확인할 수 있다.

  2. RDS image 테이블의 (name)목록을 불러온다.

    mysql을 사용할 수 있도록 다음의 의존성을 추가한다.

    implementation 'mysql:mysql-connector-java:8.0.26'

    다음과 같이 코드를 작성해서 image 테이블의 name 컬럼 값 목록을 불러오도록 했다. listImageNamesInDB() 메서드를 보면 된다.

    // Handler value: example.Handler  
    public class Handler implements RequestHandler<Map<String,String>, String> {  
      
        private static final String S3_BUCKET_NAME = "kerdy-dev";  
        private static final String S3_PATH = "dev/";  
        private static final String DB_URL = "jdbc:mysql:.../kerdy";  
        private static final String DB_USER = "user-name";  
        private static final String DB_PASSWORD = "password";  
        private AmazonS3 s3Client;  
        @Override  
        public String handleRequest(Map<String,String> event, Context context) {  
            LambdaLogger logger = context.getLogger();  
            try {  
                s3Client = AmazonS3ClientBuilder.standard()  
                        .withRegion(Regions.AP_NORTHEAST_2)  
                        .build();  
                List<String> s3ImageNames = listObjectsInS3();  
                List<String> dbImageNames = listImageNamesInDB();  
                logger.log("S3에 저장된 이미지 개수:" + s3ImageNames.size() + "개\n");  
                for (int i = 0; i < 10; i++) {  
                    logger.log(s3ImageNames.get(i));  
                }  
                logger.log("\nDB에 저장된 이미지 개수:" + dbImageNames.size() + "개\n");  
                for (int i = 0; i < 10; i++) {  
                    logger.log(dbImageNames.get(i));  
                }  
                return "Success";  
            }catch (Exception e){  
                logger.log(e.getMessage());  
                return "Error";  
            }  
        }  
        private List<String> listObjectsInS3() {  
            return s3Client.listObjects(S3_BUCKET_NAME, S3_PATH)  
                    .getObjectSummaries()  
                    .stream()  
                    .map(summary->summary.getKey().substring(S3_PATH.length()))  
                    .collect(Collectors.toList());  
        }  
      
        private List<String> listImageNamesInDB(){  
            try{  
                Class.forName("com.mysql.cj.jdbc.Driver");  
                try(Connection connection= DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)){  
                    String sql = "SELECT name FROM image";  
                    try(PreparedStatement preparedStatement = connection.prepareStatement(sql);  
                        ResultSet resultSet = preparedStatement.executeQuery()){  
                        List<String> result= new ArrayList<>();  
                        while(resultSet.next()){  
                            result.add(resultSet.getString("name"));  
                        }  
                        return result;  
                    }  
                }  
            } catch (ClassNotFoundException | SQLException e) {  
                throw new RuntimeException(e);  
            }  
        }   
    }

    DB 연동도 성공적으로 이루어졌다.

  3. 두 목록을 서로 비교하여 S3에만 존재하는 이미지들만 필터링한다.

    // Handler value: example.Handler  
    public class Handler implements RequestHandler<Map<String,String>, String> {  
      
        private static final String S3_BUCKET_NAME = "kerdy-dev";  
        private static final String S3_PATH = "dev/";  
        private static final String DB_URL = "jdbc:mysql:.../kerdy";  
        private static final String DB_USER = "user-name";  
        private static final String DB_PASSWORD = "password"; 
        private AmazonS3 s3Client;  
        @Override  
        public String handleRequest(Map<String,String> event, Context context) {  
            LambdaLogger logger = context.getLogger();  
            try {  
                s3Client = AmazonS3ClientBuilder.standard()  
                        .withRegion(Regions.AP_NORTHEAST_2)  
                        .build();  
                List<String> s3ImageNames = listObjectsInS3();  
                List<String> dbImageNames = listImageNamesInDB();  
                List<String> filteredImageNames = filterOrphanImageNames(s3ImageNames, dbImageNames);  
                logger.log("S3에 저장된 이미지 개수:" + s3ImageNames.size() + "개\n");  
                logger.log("DB에 저장된 이미지 개수:" + dbImageNames.size() + "개\n");  
                logger.log("S3에만 존재하는 이미지 개수:" + filteredImageNames.size() + "개\n");  
                for (int i = 0; i < filteredImageNames.size(); i++) {  
                    logger.log(filteredImageNames.get(i)+"\n");  
                }  
                return "Success";  
            }catch (Exception e){  
                logger.log(e.getMessage());  
                return "Error";  
            }  
        }  
        ...
      
        private List<String> filterOrphanImageNames(List<String> s3ImageNames, List<String> dbImageNames){  
            return s3ImageNames.stream()  
                    .filter(name -> !dbImageNames.contains(name))  
                    .collect(Collectors.toList());  
        } 
    }

  4. 필터링된 이미지들을 S3에서 제거한다.

    // Handler value: example.Handler  
    public class Handler implements RequestHandler<Map<String,String>, String> {  
      
        private static final String S3_BUCKET_NAME = "kerdy-dev";  
        private static final String S3_PATH = "dev/";  
        private static final String DB_URL = "jdbc:mysql:.../kerdy";  
        private static final String DB_USER = "user-name";  
        private static final String DB_PASSWORD = "password";  
        private AmazonS3 s3Client;  
        @Override  
        public String handleRequest(Map<String,String> event, Context context) {  
            LambdaLogger logger = context.getLogger();  
            try {  
                s3Client = AmazonS3ClientBuilder.standard()  
                        .withRegion(Regions.AP_NORTHEAST_2)  
                        .build();  
                List<String> s3ImageNames = listObjectsInS3();  
                List<String> dbImageNames = listImageNamesInDB();  
                List<String> filteredImageNames = filterOrphanImageNames(s3ImageNames, dbImageNames);  
                logger.log("S3에 저장된 이미지 개수: " + s3ImageNames.size() + "개\n");  
                logger.log("DB에 저장된 이미지 개수: " + dbImageNames.size() + "개\n");  
                logger.log("S3에만 존재하는 이미지 개수: " + filteredImageNames.size() + "개\n");  
                deleteFilesFromS3(filteredImageNames);  
                List<String> cleanS3ImageNames = listObjectsInS3();  
                logger.log("고아 이미지 삭제 후 남아있는 S3의 이미지 개수: " + cleanS3ImageNames.size() + "개\n");  
                return "Success";  
            }catch (Exception e){  
                logger.log(e.getMessage());  
                return "Error";  
            }  
        }  
        private List<String> listObjectsInS3() {  
            return s3Client.listObjects(S3_BUCKET_NAME, S3_PATH)  
                    .getObjectSummaries()  
                    .stream()  
                    .map(summary->summary.getKey().substring(S3_PATH.length()))  
                    .collect(Collectors.toList());  
        }  
      
        private List<String> listImageNamesInDB(){  
            try{  
                Class.forName("com.mysql.cj.jdbc.Driver");  
                try(Connection connection= DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)){  
                    String sql = "SELECT name FROM image";  
                    try(PreparedStatement preparedStatement = connection.prepareStatement(sql);  
                        ResultSet resultSet = preparedStatement.executeQuery()){  
                        List<String> result= new ArrayList<>();  
                        while(resultSet.next()){  
                            result.add(resultSet.getString("name"));  
                        }  
                        return result;  
                    }  
                }  
            } catch (ClassNotFoundException | SQLException e) {  
                throw new RuntimeException(e);  
            }  
        }  
      
        private List<String> filterOrphanImageNames(List<String> s3ImageNames, List<String> dbImageNames){  
            return s3ImageNames.stream()  
                    .filter(name->!dbImageNames.contains(name))  
                    .collect(Collectors.toList());  
        }  
      
        private void deleteFilesFromS3(List<String> imageNames) {  
            for (String imageName : imageNames) {  
                s3Client.deleteObject(new DeleteObjectRequest(S3_BUCKET_NAME, S3_PATH+imageName));  
            }  
        }  
    }

    이렇게 S3에 남아있는 고아 이미지를 식별해 삭제하는 Lambda 함수 코드가 완성됐다.

7. EventBridge Scheduler 만들기

이제 이 코드가 일정 주기마다 자동으로 돌아가게끔 설정해주어야 한다. AWS의 EventBridge 콘솔에 접속하여, 시작하기 섹션에서 'EventBridge 일정'을 선택하고 규칙 생성 버튼을 클릭한다.

이름을 짓고 반복 패턴을 설정해준다. 매일 자정마다 이벤트가 실행되도록 0 0 * * ? *이라는 cron 식을 사용했다.

그 다음, 대상 선택 페이지로 넘어가 대상 세부 정보에서 AWS Lambda를 선택해주고

실행하고자 하는 Lambda 함수를 선택하고 테스트 데이터를 작성한다.

이렇게 하고 EventBridge 일정을 생성해주면, 이제 매일 자정마다 고아 이미지를 삭제해주는 Lambda 함수가 실행된다.

참고 자료



Loading script...